Phase 1: token system, build pipeline, release automation, docs, and eight components - #1
Merged
Conversation
tsup's last npm release was 2025-11-12 (nine months ago) with 409 open issues; tsdown ships actively under the rolldown org and targets libraries specifically. Neither is formally deprecated on npm, but the maintenance signal is one-sided. tsdown externalizes dependencies and peerDependencies by default, so the explicit external array is gone — react, react-dom and @base-ui/react are external without configuration. tsdown's own external option is deprecated in favour of deps.neverBundle; neither is needed here. Also documents that clean: true makes build order load-bearing: tsdown must run before build-css.mjs or dist/dowel.css is deleted after it is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-flight scan: every component called cx("dowel-x") with exactly one
string literal, so cx was an identity function at all 18 call sites. The
design expresses variants as data-* attributes, so no component ever builds
a conditional class name and the helper has no use case.
Components now write className="dowel-x" directly. src/lib/cx.ts is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The name-set parity test pools both dark copies into one Set, so a value edited in one copy but not the other -- or a token dropped from a single copy -- shipped with a green suite. Split dark.css on the media-query boundary and assert the two copies' name->value maps are deeply equal. Also point light.css's header at scale.css, where --dowel-hue now lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… test tsdown emits ESM + d.ts from an empty barrel; build-css.mjs bundles the token CSS through Lightning CSS into one minified dist/dowel.css. The css-contract suite asserts the bundle exists, resolves every --dowel- var() it references, inlines all @imports, and keeps the cascade layers. tsdown's clean:true wipes dist/, so the build script runs tsdown first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tyling
Review fixes for Task 4, all three replicate into Tasks 5-10:
- Spread props before className/style/data-* so consumers cannot smuggle
overrides through a wider object; guarded by a new test.
- Forward Base UI's nativeButton so render={<a/>} produces valid DOM
(no type attr, no dev warning); two tests updated/added.
- Style aria-disabled like :disabled for non-form-control renders.
- renderBoth docstring now states what jsdom can actually check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review residuals, each copied by Tasks 5-10: - Variant hover rules now exclude [aria-disabled=true] so disabled anchors stay inert. Verified in dist/dowel.css; no jsdom test, since @layer rules and :hover styling are invisible to it and the test could not fail. - Spread-order comment narrowed: render's element keeps its own attributes by design. - renderBoth docstring points at tokens.test.ts, the actual parity guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two plain-span display components with no Base UI dependency. Both follow
the Task 4 hardening pattern: consumer props spread first, then className,
style={undefined}, and data-* so a spread can never strip the dowel class
or inject inline style. Kbd omits children from its props — the keys array
is the only content source, and JSX children beat any smuggled children.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field parts previously typed their props from intrinsic div/p elements, which dropped Base UI's invalid/validate/validationMode/match and made the exported Field.Error unreachable through the typed API. Props now derive from each Base UI component's call signature (className/style still omitted). Field.Label additionally omits htmlFor — a hand-written htmlFor wins over the generated association and silently breaks label click-to-focus. Field.Error's ref is HTMLDivElement, matching what Base UI renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 7 hit TS2344: ComponentProps<T> rejects the (...args: never) => unknown constraint. Tasks 8, 9 and 10 all carried the same non-compiling helper, so fix it in the plan before their briefs are generated rather than three more times in review. Replaced with the form Task 7 proved compiles (infer the props parameter from the call signature) and dropped the now-unused ComponentProps import from all three component files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… return Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Provider and Root stay raw passthroughs — verified against Base UI's .d.ts: neither renders an HTML element and neither's props carry className/style. Trigger, Portal, Positioner and Popup all extend BaseUIComponentProps (button/div/div/div), so all four are wrapped with the spread-first neutralisation pattern. Base UI 1.7 leaves the popup role-less (and wires no aria-describedby), so dowel's Popup sets role="tooltip" as an overridable default — accurate for a plain text label, and what makes role-based queries work at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base UI's role-less popup is documented design stance, not a limitation: their shipped docs state tooltips are visual labels only and the trigger must carry an aria-label closely matching the tooltip content. Record that contract above Popup's role="tooltip" default and pin it with a test — the role+name query runs the full accessible-name computation, asserted with the popup closed because assistive tech never sees it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- .changeset/config.json ships ignore: []; Task 13 adds @dowel/docs in the same commit that creates the package (changesets hard-errors on ignore entries that match no package, and with the docs app present but unignored it writes a phantom patch bump — the entry must move, not vanish). - --dowel-accent-hover is now color-mix-derived from --dowel-accent (92% toward black in light, 85% toward white in dark) so a retheme keeps its hover. Percentages chosen by resolving candidates with Lightning CSS against the old hardcoded hovers. - Both READMEs now name the real theming surface: --dowel-hue, --dowel-accent, --dowel-accent-fg. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs dogfood dowel: every component on the page comes from the workspace package through its published exports, so a broken export map breaks the docs build rather than a consumer's. Prerendered to static HTML. Docs discovery is search-driven and nothing here needs a request, so the deploy target is an assets-only Worker with no runtime. The client environment writes straight to dist/ instead of the default dist/client, which makes the deploy "upload dist/" with no server bundle sitting next to the HTML; the SSR build exists only to render those pages, so it goes to .tanstack/ and is gitignored. The brief's app.config.ts shape does not exist in Start 1.168 — there is no @tanstack/react-start/config export at all. Configuration is a Vite plugin now (@tanstack/react-start/plugin/vite), and `server.preset: "static"` is replaced by `pages` + `prerender`. Seeding the crawler with "/" and letting crawlLinks follow the nav means a new route linked from the shell prerenders without touching the config. Two constraints the docs have to state, because both are invisible from the type signatures: dowel ships no typeface — it names "Inter Variable" first in --dowel-font and expects the app to supply it, which the docs do via @fontsource-variable/inter — and a Tooltip is a visual label only. Base UI deliberately writes no aria-describedby, so the trigger has to carry its own accessible name and hover content that must reach assistive tech belongs in a Popover. Changesets ignores @dowel/docs. The package is private, and with an empty ignore list changesets writes a phantom 0.0.1 bump and a CHANGELOG for a package that is never published. The .prettierignore fix is not cosmetic: `docs/` is a gitignore-style pattern that matches a directory of that name at any depth, so adding apps/docs silently excluded the entire new app from format:check. Anchoring it to /docs/ puts the app back under the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assets-only Worker config (html_handling auto-trailing-slash so directory sub-pages resolve; not_found_handling none — no 404.html is emitted) plus a main-branch deploy workflow that skips cleanly when the org CLOUDFLARE_API_TOKEN is absent and pins wrangler-action to wrangler v4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Document theming, the one thing the READMEs never explained, and clear the four minors left open across the branch. - Theming section in packages/dowel/README.md, verified against source: the three dark paths (.dowel-dark, [data-dowel-theme="dark"], and the prefers-color-scheme rule on :root:not(.dowel-light):not([data-dowel-theme="light"])), what .dowel-root actually supplies, and a retheming snippet that states the :root requirement. Custom properties resolve on the declaring element, so a nested --dowel-accent override leaves the color-mix-derived hover behind. Root README aligned but kept short; both now state dowel is ESM-only. - user-select: none on .dowel-icon-btn, matching button.css and menu.css. - pretest builds before the CSS contract suite, so a fresh clone no longer fails four tests with "has not been built". Not circular and ~2.4s; the test keeps a named message as a backstop for bare vitest runs. - src/index.ts drops the "appended here by each component task" scaffolding. - Docs site gets a light/dark toggle in the nav, dogfooding IconButton and driving data-dowel-theme from state. No DOM access, so prerendering is unaffected and all three pages still emit. Covered by a new onClick-forwarding test on IconButton. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old lch(49% 62 285) rendered as a saturated azure that read as generic Bootstrap blue. Teal is karnstack's house colour, and hue 195 at a restrained chroma is ours rather than a borrowed brand. Light is lch(52% 32 195) (#198b89). Dark lifts it to lch(68% 36 195) so it carries on near-black surfaces — 7.71:1 against bg-1. Chroma runs out sooner than it looks at this hue: lch(64% 40 195) and anything more saturated at that lightness clips the sRGB green channel, so the dark value sits just inside the edge. --dowel-accent-fg now differs by theme, which is the substantive change. Teal is luminous for its lightness, so the dark accent leaves white text at 2.43:1 — worse than the azure's already-failing 3.31:1. Dark therefore draws a near-black ink from the accent's own hue: 6.42:1 at rest, 7.44:1 on hover. Light keeps white at 4.12:1. That is under AA, and deliberately so: against an L=52 background no ink clears 4.5:1 in both states — black trades the numbers round at 5.10:1 rest and 4.20:1 hover. White wins on the hover state and reads visibly cleaner at 13px. Reaching AA at rest needs the accent itself to drop to about lch(49% 32 195), which is a call for whoever owns the brand value. The color-mix() hover derivations are untouched, so overriding --dowel-accent still carries into hover and focus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs rendered as unstyled prose on a dark background. This gives them
shadcn's three-column shape: sticky header, sticky sidebar nav, a content
column at 72ch, and an "On this page" rail with scroll-spy — collapsing to one
column with a hamburger disclosure below 64rem.
Plain CSS on dowel's own tokens throughout. No Tailwind, in the library or the
docs. Local --docs-* tokens exist only where dowel has none: page-scale
spacing (dowel's space scale tops out at 18px, sized for the inside of a
control), layout widths, and syntax colours.
Demos weld the preview surface and its source into one bordered container so
the two cannot drift, and every preview is real dowel imported from the
package. Syntax highlighting is a ~60-line TSX lexer rather than a dependency —
Shiki and Prism are each larger than everything else in this app combined, and
the docs only ever highlight snippets we wrote. It is a pure string -> tokens
function, so the prerender can run it.
Eleven pages prerender: landing, an index, and eight component pages.
The theme toggle now defaults to the OS rather than hardcoding dark. That is
also the only hydration-safe initial value — the server cannot read the OS
setting, so asserting nothing is the one choice that always matches the
client. Both icons ship and CSS picks the one matching the resolved theme,
which a JS-chosen icon could not do in prerendered markup.
Two library fixes fell out of dogfooding: Button and IconButton rendered
through the documented render={<a />} escape hatch were arriving underlined
from the UA sheet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing D — bare, either case — flips the docs theme that the header toggle already flips, and the toggle's tooltip advertises it with Kbd. The listener is attached in an effect so it only ever exists in the browser; the site is statically prerendered and there is no document while the HTML is generated. Three guards keep a bare-letter shortcut from stealing keystrokes. It yields to anything the user is typing into — input, textarea, select, or a computed contenteditable, so nodes nested inside an editing host count too. It yields to a held Meta/Ctrl/Alt so it can never shadow a browser shortcut, and to defaultPrevented so a handler closer to the keystroke wins. And it yields while a Dialog or Menu is open, matched on Base UI's [data-open] rather than the popup's presence in the DOM: the attribute is dropped for the closing animation while the element is still mounted, so presence would suppress the key after the overlay had visually gone. toggleTheme now reads the media query before setTheme and resolves the current theme in a functional updater, which keeps the updater pure and leaves the toggle depending on nothing — so the listener is attached once instead of rebound on every flip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pending minor changeset would have published dowel@0.1.0 the moment this branch merged. More refactors are queued before the API is worth freezing, so it goes. Changesets then fails CI on "packages changed, no changeset found" — the whole branch is new relative to main — so an empty changeset takes its place, which is the documented way to say this change needs no release. `changeset status` is green again and dowel stays at 0.0.0. The docs had no favicon. The mark is the header wordmark's pin (a dowel: the small turned pin that joins two pieces) set into an accent tile. The bare pin is right at 18px against the page background, but a favicon is 16px on chrome the site does not control, and there a lone diagonal capsule reads as a faint slash on light chrome and vanishes into dark. The tile brings its own contrast, so the mark holds either way, and the pin knocks out of it at a width that survives 16px. icon.svg is primary and follows the OS scheme via prefers-color-scheme, tracking dowel-accent under both. favicon.ico (16 and 32) and apple-touch-icon.png cover what will not take an SVG. They are declared on the root route, so all eleven prerendered pages carry them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The notes existed in the worktree but were never tracked, so nothing in a fresh checkout carried them. The dash rule in particular has to live in the repo to be worth anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The badge was hand-typed as v0.1.0 in two places while the package sits at 0.0.0 with nothing published, so the site advertised a release that does not exist. Hardcoding it also guarantees the same drift on the next bump. vite.config.ts now imports packages/dowel/package.json by relative path and bakes the version in with `define`. The relative path is required: dowel's exports map does not expose ./package.json, so the bare specifier cannot resolve. A `define` is a literal substitution, which is what keeps it working under static prerendering, where there is no runtime to read a file. src/lib/version.ts turns that into the copy both call sites render. While the manifest holds the unpublished 0.0.0 placeholder the badge reads "unreleased", because a version number nobody can install is worse than no number. It is a condition on the value, not a second string, so the first real publish flips it to v<version> with no docs change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of dowel: the token system, build pipeline, release automation, docs site, and eight components — end to end.
What ships
dowel@0.1.0— a real npm package.pnpm add dowel, import a component, import one stylesheet. No Tailwind, no PostCSS config, no preset, no copy-in generator.Eight components, all Base UI-backed where behaviour exists:
Button · IconButton · Badge · Kbd · Input + Field · Dialog · Menu · Tooltip
Design foundation measured from a live audit of linear.app, not guessed. Three rules carry most of the look:
lch(L C 272)at chroma 0.4–1.93 — the greys aren't grey-0.02emtracking0.15s, and onlyborder, background-color, color, opacityever animatePlus
0.5pxhairlines and exactly two elevation tiers (3-layer popover, 5-layer modal).Docs at
apps/docs— TanStack Start, prerendered to static HTML, dogfooding dowel, with a working light/dark toggle. Deploys to dowel.sh as an assets-only Cloudflare Worker.The opinion, enforced
There is no per-component override API.
classNameandstyleareOmited from every public prop type, and every component spreads{...props}before its own attributes so a consumer spread can't strip them either. Retheming is three CSS variables:--dowel-hue,--dowel-accent,--dowel-accent-fg.The final review typechecked a real consumer file against the built
dist/index.d.tsand confirmed all 37 override attempts across every part — including the compound Roots — are type errors.Homage
dowel reimplements Linear's visual language — colour relationships, spacing, density, radii, motion curves — which is not protectable. It ships none of their assets: no logo, no icons, no licensed typefaces. The README credits them and states plainly that dowel is unaffiliated and unendorsed.
How it was built
14 tasks, each implemented by a fresh subagent and independently reviewed, with adversarial verification of every claim. The review loop caught defects that would otherwise have shipped, including:
<Button {...{className:"evil"}} />typechecked clean and strippeddowel-btnentirely, leaving components unstyled. Caught at Button, so fixed once instead of eight times.Dialog.Portalinline-style channel — Base UI's Portal renders a real<div>; a consumertransformthere creates a containing block and silently breaks the dialog's positioning.Field.Errorwas unreachable — prop types derived from"div"/"p"instead of Base UI droppedinvalid/validate/match. We were about to export a component no type-checking consumer could render.getByRolemasked it.--dowel-accent-hoverwas hardcoded, so overriding the accent gave a Button that snapped back to default purple on hover. Nowcolor-mix-derived.Several of those were defects in the plan, not the implementations.
Verification
axe-coreper componentvar(--dowel-*)in the emitted CSSformat:check,typecheck,build,testall green in CI orderPost-merge (not doable before)
curl https://dowel.sh→ 200. Custom domains take a few minutes to attach; a first-try 522/404 isn't a bug.dowel@0.1.0, then verify the npm dist-tag.www.dowel.shneeds a Cloudflare dashboard Redirect Rule — an assets-only Worker can't do it.Known follow-ups
dowel.shreturns a bare 404 on unknown URLs — add a404.htmland flipnot_found_handling*Propstypes; typed wrappers needComponentProps<typeof Dialog.Popup>aria-describedby— Base UI's documented stance is that tooltips are visual labels only.IconButton's requiredlabelguarantees the accessible name; wiring it ourselves is a recorded phase-2 decision.🤖 Generated with Claude Code